{T}

手写 Jest:从零实现一个简易版测试框架

目录

前言

Jest 是前端领域最流行的测试框架之一,它提供了简洁的 API、强大的 Mock 功能和完善的测试生命周期管理。然而,很多人使用了多年 Jest,却依然不了解它的内部实现原理。

本文将带你从零开始实现一个简易版的 Jest 测试框架,通过实际编码来深入理解 Jest 的核心机制。将基于 Node.js 的 VM 模块来实现代码的隔离执行,并通过模块缓存机制来实现 Mock 功能。

实现背景与设计思路

为什么要手写 Jest?

  1. 理解测试框架本质:测试框架并不神秘,它本质上就是代码执行器 + 断言库 + 报告生成器
  2. 掌握 VM 模块应用:Node.js 的 VM 模块提供了安全的代码执行环境,是构建测试框架的基础
  3. 学习 Mock 实现原理:通过操作 require.cache 来实现模块 Mock,理解其工作机制
  4. 提升调试能力:了解框架内部实现有助于更好地调试测试用例

设计目标

我们的简易版 Jest 将包含以下核心功能:

  • ✅ 测试用例定义(test/it)
  • ✅ 断言库(expect/matchers)
  • ✅ 生命周期钩子(beforeAll/afterAll/beforeEach/afterEach)
  • ✅ 函数 Mock(jest.fn)
  • ✅ 模块 Mock(jest.mock)
  • ✅ 测试结果报告

技术架构

text
┌─────────────────────────────────────────┐
│           测试用例文件 (*.test.js)       │
├─────────────────────────────────────────┤
│           VM 执行环境                  │
│  ┌─────────────────────────────────────┐ │
│  │     全局 API 注入                  │ │
│  │  - test, expect, jest.fn, jest.mock │ │
│  │  - beforeAll, afterAll, beforeEach  │ │
│  │  - afterEach                        │ │
│  └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│           测试执行引擎                  │
│  ┌─────────────────────────────────────┐ │
│  │   1. 收集测试用例                  │ │
│  │   2. 执行生命周期钩子              │ │
│  │   3. 运行测试用例                  │ │
│  │   4. 收集测试结果                  │ │
│  └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│           结果报告生成                  │
└─────────────────────────────────────────┘

Jest 核心概念回顾

基本使用示例

创建测试项目:

bash
mkdir jest-test
cd jest-test
npm init -y

安装 Jest:

bash
npm install --save-dev jest @types/jest

创建被测试的模块 sum.js

javascript
function sum(a, b) {
  return a + b
}

module.exports = sum

编写测试用例 sum.test.js

javascript
const sum = require("./sum")

test("sum test", () => {
  expect(sum(1, 2)).toBe(3)
})

运行测试:

bash
npx jest

断言(Matchers)

Jest 提供了丰富的断言方法:

javascript
expect(value).toBe(expected) // 严格相等
expect(value).toEqual(expected) // 深度相等
expect(value).toBeGreaterThan(number) // 大于
expect(value).toBeLessThan(number) // 小于
expect(value).toContain(item) // 包含
expect(value).toBeInstanceOf(Class) // 实例类型
expect(fn).toThrow(error) // 抛出异常

Mock 功能

函数 Mock

javascript
const mockFn = jest.fn()
mockFn(1, 2, 3)

expect(mockFn).toHaveBeenCalled() // 是否被调用
expect(mockFn).toHaveBeenCalledTimes(1) // 调用次数
expect(mockFn).toHaveBeenCalledWith(1, 2, 3) // 调用参数

模块 Mock

javascript
jest.mock("fs")
const fs = require("fs")

fs.readFileSync.mockReturnValue("mocked content")

生命周期钩子

javascript
beforeAll(() => {
  // 所有测试开始前执行一次
})

afterAll(() => {
  // 所有测试结束后执行一次
})

beforeEach(() => {
  // 每个测试开始前执行
})

afterEach(() => {
  // 每个测试结束后执行
})

技术原理分析

VM 模块的作用

Node.js 的 VM 模块提供了在 V8 虚拟机中编译和运行代码的能力,它可以创建一个隔离的执行环境:

javascript
const vm = require("vm")

const context = {
  console,
  globalVar: "Hello VM"
}

vm.createContext(context)

// 在隔离环境中执行代码
vm.runInContext("console.log(globalVar)", context)

模块缓存机制

Node.js 的 require.cache 存储了已加载模块的缓存:

javascript
// 查看模块缓存
console.log(require.cache)

// 手动修改模块缓存
require.cache["/path/to/module.js"] = {
  id: "/path/to/module.js",
  filename: "/path/to/module.js",
  loaded: true,
  exports: {
    /* 自定义导出 */
  }
}

错误堆栈解析

当代码抛出异常时,可以通过错误堆栈获取出错位置:

javascript
try {
  throw new Error("Test error")
} catch (error) {
  console.log(error.stack)
  // 输出类似:
  // Error: Test error
  //     at Object.<anonymous> (/path/to/file.js:3:9)
}

核心功能实现

1. 全局状态管理

首先实现一个全局状态管理器,用于存储测试用例和生命周期钩子:

javascript
const createState = () => {
  global["STATE"] = {
    testBlock: [], // 测试用例数组
    beforeEachBlock: [], // beforeEach 钩子数组
    beforeAllBlock: [], // beforeAll 钩子数组
    afterEachBlock: [], // afterEach 钩子数组
    afterAllBlock: [], // afterAll 钩子数组
    reports: [] // 测试结果报告
  }
}

const dispatch = (event) => {
  const { fn, type, name, pass } = event
  const state = global["STATE"]

  switch (type) {
    case "ADD_TEST":
      state.testBlock.push({ fn, name })
      break
    case "BEFORE_EACH":
      state.beforeEachBlock.push(fn)
      break
    case "BEFORE_ALL":
      state.beforeAllBlock.push(fn)
      break
    case "AFTER_EACH":
      state.afterEachBlock.push(fn)
      break
    case "AFTER_ALL":
      state.afterAllBlock.push(fn)
      break
    case "COLLECT_REPORT":
      state.reports.push({ name, pass })
      break
  }
}

2. 测试 API 实现

实现 test、beforeAll、beforeEach 等 API:

javascript
const test = (name, fn) => dispatch({ type: "ADD_TEST", fn, name })
const afterAll = (fn) => dispatch({ type: "AFTER_ALL", fn })
const afterEach = (fn) => dispatch({ type: "AFTER_EACH", fn })
const beforeAll = (fn) => dispatch({ type: "BEFORE_ALL", fn })
const beforeEach = (fn) => dispatch({ type: "BEFORE_EACH", fn })

3. 断言库实现

实现 expect 断言库,包含常用的 matcher:

javascript
const expect = (actual) => ({
  toBe(expected) {
    if (actual !== expected) {
      throw new Error(`Expected ${actual} to be ${expected}`)
    }
  },

  toEqual(expected) {
    if (JSON.stringify(actual) !== JSON.stringify(expected)) {
      throw new Error(
        `Expected ${JSON.stringify(actual)} to equal ${JSON.stringify(expected)}`
      )
    }
  },

  toBeGreaterThan(expected) {
    if (actual <= expected) {
      throw new Error(`Expected ${actual} to be greater than ${expected}`)
    }
  },

  toBeLessThan(expected) {
    if (actual >= expected) {
      throw new Error(`Expected ${actual} to be less than ${expected}`)
    }
  },

  toContain(expected) {
    if (!actual.includes(expected)) {
      throw new Error(`Expected ${actual} to contain ${expected}`)
    }
  },

  toBeInstanceOf(expected) {
    if (!(actual instanceof expected)) {
      throw new Error(`Expected ${actual} to be instance of ${expected.name}`)
    }
  },

  toThrow(expected) {
    let thrown = false
    let error

    try {
      actual()
    } catch (e) {
      thrown = true
      error = e
    }

    if (!thrown) {
      throw new Error(`Expected function to throw, but it didn't`)
    }

    if (expected && !error.message.includes(expected)) {
      throw new Error(
        `Expected error message to include "${expected}", but got "${error.message}"`
      )
    }
  }
})

4. Mock 功能实现

函数 Mock

javascript
const jest = {
  fn(impl = () => {}) {
    const mockFn = (...args) => {
      mockFn.mock.calls.push(args)
      return impl(...args)
    }

    mockFn.originImpl = impl
    mockFn.mock = { calls: [] }

    // 添加 Mock 方法
    mockFn.mockReturnValue = (value) => {
      mockFn.originImpl = impl
      impl = () => value
      return mockFn
    }

    mockFn.mockImplementation = (newImpl) => {
      impl = newImpl
      return mockFn
    }

    return mockFn
  }
}

模块 Mock

javascript
const jest = {
  // ... 前面的代码

  mock(mockPath, mockExports = {}) {
    try {
      const path = require.resolve(mockPath, { paths: ["."] })
      require.cache[path] = {
        id: path,
        filename: path,
        loaded: true,
        exports: mockExports
      }
    } catch (error) {
      console.warn(`Warning: Could not resolve module ${mockPath}`)
    }
  }
}

5. 测试执行引擎

实现测试用例的执行逻辑:

javascript
const executeTests = async () => {
  const state = global["STATE"]

  // 执行 beforeAll 钩子
  for (const beforeAllFn of state.beforeAllBlock) {
    try {
      await beforeAllFn()
    } catch (error) {
      console.error(`beforeAll hook failed:`, error)
      return
    }
  }

  // 执行测试用例
  for (const testCase of state.testBlock) {
    const { fn, name } = testCase

    try {
      // 执行 beforeEach 钩子
      for (const beforeEachFn of state.beforeEachBlock) {
        await beforeEachFn()
      }

      // 执行测试用例
      await fn()

      // 记录成功结果
      dispatch({ type: "COLLECT_REPORT", name, pass: 1 })
      console.log(`✓ ${name}`)

      // 执行 afterEach 钩子
      for (const afterEachFn of state.afterEachBlock) {
        await afterEachFn()
      }
    } catch (error) {
      // 记录失败结果
      dispatch({ type: "COLLECT_REPORT", name, pass: 0 })
      console.error(`✗ ${name}`)
      console.error(`  ${error.message}`)

      // 即使失败也要执行 afterEach
      for (const afterEachFn of state.afterEachBlock) {
        try {
          await afterEachFn()
        } catch (afterError) {
          console.error(`afterEach hook failed:`, afterError)
        }
      }
    }
  }

  // 执行 afterAll 钩子
  for (const afterAllFn of state.afterAllBlock) {
    try {
      await afterAllFn()
    } catch (error) {
      console.error(`afterAll hook failed:`, error)
    }
  }

  // 生成测试报告
  generateReport()
}

const generateReport = () => {
  const { reports } = global["STATE"]
  const passed = reports.filter((r) => r.pass === 1).length
  const total = reports.length

  console.log(`\nTest Results: ${passed}/${total} passed`)

  if (passed === total) {
    console.log("All tests passed! 🎉")
  } else {
    console.log(`${total - passed} tests failed ❌`)
    process.exit(1)
  }
}

完整代码实现

以下是完整的 my-jest.js 实现: